import { destroy, getEnv, getParent, getRoot, types } from "mobx-state-tree";

import { errorBuilder } from "../../core/DataValidator/ConfigValidator";
import { DataValidator, ValidationError, VALIDATORS } from "../../core/DataValidator";
import { guidGenerator } from "../../core/Helpers";
import Registry from "../../core/Registry";
import Tree from "../../core/Tree";
import Types from "../../core/Types";
import { StoreExtender } from "../../mixins/SharedChoiceStore/extender";
import { ViewModel } from "../../tags/visual";
import Utils from "../../utils";
import { FF_DEV_3034, FF_DEV_3391, FF_SIMPLE_INIT, isFF } from "../../utils/feature-flags";
import { emailFromCreatedBy } from "../../utils/utilities";
import { Annotation } from "./Annotation";
import { HistoryItem } from "./HistoryItem";

const SelectedItem = types.union(Annotation, HistoryItem);

const localStorageKeys = {
  viewingAll: "annotation-store-viewing-all",
};

const AnnotationStoreModel = types
  .model("AnnotationStore", {
    selected: types.maybeNull(types.reference(SelectedItem)),
    selectedHistory: types.maybeNull(types.safeReference(SelectedItem)),

    root: Types.allModelsTypes(),
    names: types.map(types.reference(Types.allModelsTypes())),
    toNames: types.map(types.array(types.reference(Types.allModelsTypes()))),

    annotations: types.array(Annotation),
    predictions: types.array(Annotation),
    history: types.array(HistoryItem),

    viewingAllAnnotations: types.optional(
      types.boolean,
      // Initialize from localStorage, defaulting to false if not set
      () => {
        return window.localStorage.getItem(localStorageKeys.viewingAll) === "true";
      },
    ),

    validation: types.maybeNull(types.array(ValidationError)),
  })
  .volatile(() => ({
    initialized: false,
  }))
  .views((self) => ({
    get store() {
      return getRoot(self);
    },

    get viewingAll() {
      // Even if we have View All flag stored as true, but we are in environment without it
      // or if we are not ready yet — don't go into View All mode, it will be broken.
      if (!self.initialized) return false;
      if (!self.store.hasInterface("annotations:view-all")) return false;
      return self.viewingAllAnnotations;
    },
  }))
  .actions((self) => {
    function toggleViewingAll() {
      self.viewingAllAnnotations = !self.viewingAllAnnotations;
      // Persist the state to localStorage
      window.localStorage.setItem(localStorageKeys.viewingAll, String(self.viewingAllAnnotations));

      if (self.viewingAllAnnotations) {
        if (self.selected) {
          // const comments = self.store.commentStore;

          // @todo `currentComment` is an object and saving them was not a part of original fix
          // @todo so I leave it for next fix coming soon
          // if (comments.currentComment) {
          //   // comment will save draft automatically
          //   comments.commentFormSubmit();
          // } else
          if (self.selected.type === "annotation") {
            // save draft if there are changes waiting to be saved — it's handled inside
            self.selected.saveDraftImmediately();
          }

          self.selected.unselectAll();
          self.selected.selected = false;
        }

        // When FF_SIMPLE_INIT is enabled, we need to ensure all tags have their results set from the annotations.
        // This process is normally done via selecting explicitly all annotations but, when the flag is enabled, it is
        // skipped in AppStore::initializeStore to enforcing better performance.
        // The byproduct of this performance improvement is that we do not update the objects with their corresponding
        // results and, when entering the ViewAll mode, we can only see the results updated for the previously selected
        // annotation but not the rest of them.
        // This fix aims to mimic the behaviour of selectAnnotation when it comes to updating the objects only without
        // actually executing the full process of selecting the annotation.
        if (isFF(FF_SIMPLE_INIT)) {
          [...self.predictions, ...self.annotations].forEach((a) => {
            // Skip the current annotation as it's already handled
            if (a === self.selected) return;

            // Set results for each annotation without selecting it
            a.updateObjects();
          });
        }

        self.annotations.forEach((a) => {
          a.editable = false;
        });
      } else {
        selectAnnotation(self.annotations.at(isFF(FF_SIMPLE_INIT) ? -1 : 0).id, { fromViewAll: true });
      }
    }

    // @todo that's just an alias, rewrite it everywhere
    function toggleViewingAllAnnotations() {
      toggleViewingAll();
    }

    function unselectViewingAll() {
      self.viewingAllAnnotations = false;
      // Persist the state to localStorage
      window.localStorage.setItem(localStorageKeys.viewingAll, String(self.viewingAllAnnotations));
    }

    function _unselectAll() {
      if (self.selected) {
        self.selected.unselectAll();
        self.selected.selected = false;
      }
    }

    // used only in old version of View All — Grid.jsx
    function _selectItem(item) {
      self._unselectAll();
      item.editable = false;
      item.selected = true;
      self.selected = item;
      item.updateObjects();
    }

    // Select annotation or prediction
    function selectItem(id, list, resetHistory = true) {
      // might be better to protect this change with FF_SIMPLE_INIT
      // unselectViewingAll();

      self._unselectAll();

      // sad hack with pk while sdk are not using pk everywhere
      const c = list.find((c) => c.id === id || c.pk === String(id)) || list[0];

      if (!c) return null;
      c.selected = true;

      if (resetHistory) {
        self.selectedHistory = null;
        self.history = [];
      }

      self.selected = c;

      c.updateObjects();
      if (c.type === "annotation") c.setInitialValues();

      return c;
    }

    /**
     * Select annotation
     * @param {*} id
     */
    function selectAnnotation(id, options = {}) {
      // `selectAnnotation()` is used a lot during init, usually for all annotations.
      // It should only exit View All mode when it's explicitly requested.
      // All other calls are just setting things up and should not affect View All mode.
      if (options.exitViewAll) {
        unselectViewingAll();
      }

      if (!self.annotations.length) return null;

      const { selected } = self;
      const c = selectItem(id, self.annotations, !options.retainHistory);

      c.editable = true;
      c.setupHotKeys();

      getEnv(self).events.invoke("selectAnnotation", c, selected, options ?? {});
      if (c.pk) getParent(self).addAnnotationToTaskHistory(c.pk);
      return c;
    }

    function selectPrediction(id, options = {}) {
      // The same logic as in `selectAnnotation()`
      if (options.exitViewAll) {
        unselectViewingAll();
      }

      return selectItem(id, self.predictions);
    }

    function clearDeletedParents(annotation) {
      if (!annotation?.pk) return;
      self.annotations.forEach((anno) => {
        if (anno.parent_annotation && +anno.parent_annotation === +annotation.pk) {
          anno.parent_annotation = null;
        }
      });
    }

    function deleteAnnotation(annotation) {
      getEnv(self).events.invoke("deleteAnnotation", self.store, annotation);

      /**
       * MST destroy annotation
       */
      destroy(annotation);

      /**
       * Clear any other parent_annotations connected to this annotation
       */
      self.clearDeletedParents(annotation);

      self.selected = null;
      /**
       * Select other annotation
       */
      if (self.annotations.length > 0) {
        self.selectAnnotation(self.annotations[0].id);
      }
    }

    function showError(err) {
      if (err) self.addErrors([errorBuilder.generalError(err)]);
      // we have to return at least empty View to display interface
      return (self.root = ViewModel.create({ id: "error" }));
    }

    function upsertToName(node) {
      const val = self.toNames.get(node.toname);

      if (val) {
        val.push(node.name);
      } else {
        self.addToName(node);
      }
    }

    function addToName(node) {
      self.toNames.set(node.toname, [node.name]);
    }

    function addName(node) {
      self.names.put(node);
    }

    function initRoot(config) {
      if (self.root) return;

      if (!config) {
        return (self.root = ViewModel.create({ id: "empty" }));
      }

      // convert config to mst model
      let rootModel;

      try {
        rootModel = Tree.treeToModel(config, self.store);
      } catch (e) {
        console.error(e);
        return showError(e);
      }
      const modelClass = Registry.getModelByTag(rootModel.type);
      // hacky way to get all the available object tag names
      const objectTypes = Registry.objectTypes().map((type) => type.name.replace("Model", "").toLowerCase());
      const objects = [];

      self.validate(VALIDATORS.CONFIG, rootModel);

      try {
        self.root = modelClass.create(rootModel);
      } catch (e) {
        console.error(e);
        return showError(e);
      }

      if (isFF(FF_DEV_3391)) {
        // initialize toName bindings [DOCS] name & toName are used to
        // connect different components to each other
        const { names, toNames } = Tree.extractNames(self.root);

        names.forEach((tag) => self.names.put(tag));
        toNames.forEach((tags, name) => self.toNames.set(name, tags));

        Tree.traverseTree(self.root, (node) => {
          if (self.store.task && node.updateValue) node.updateValue(self.store);
        });

        self.initialized = true;

        return self.root;
      }

      // initialize toName bindings [DOCS] name & toName are used to
      // connect different components to each other
      Tree.traverseTree(self.root, (node) => {
        if (node?.name) {
          self.addName(node);
          if (objectTypes.includes(node.type)) objects.push(node.name);
        }

        const isControlTag = node.name && !objectTypes.includes(node.type);

        // auto-infer missed toName if there is only one object tag in the config
        if (isControlTag && !node.toname && objects.length === 1) {
          node.toname = objects[0];
        }

        if (node && node.toname) {
          self.upsertToName(node);
        }

        if (self.store.task && node.updateValue) node.updateValue(self.store);
      });

      self.initialized = true;

      return self.root;
    }

    function findNonInteractivePredictionResults() {
      return self.predictions.reduce((results, prediction) => {
        return [
          ...results,
          ...prediction._initialAnnotationObj
            .filter((result) => result.interactive_mode === false)
            .map((r) => ({ ...r })),
        ];
      }, []);
    }

    function createItem(options) {
      const { user, config } = self.store;

      if (!self.root) initRoot(config);

      let pk = options.pk || options.id;

      if (options.type === "annotation" && pk && isNaN(pk)) {
        /* something happened where our annotation pk was replaced with the id */
        pk = self.annotations?.[self.annotations.length - 1]?.storedValue?.pk;
      }

      //
      const node = {
        userGenerate: false,
        createdDate: Utils.UDate.currentISODate(),

        ...options,

        // id is internal so always new to prevent collisions
        id: guidGenerator(5),
        // pk and id may be missing, so undefined | string
        pk: pk && String(pk),
        root: options.root ?? self.root,
      };

      if (user && !("createdBy" in node)) node.createdBy = user.displayName;
      if (options.user) node.user = options.user;

      return node;
    }

    function addPrediction(options = {}) {
      options.editable = false;
      options.type = "prediction";

      const item = createItem(options);

      if (isFF(FF_SIMPLE_INIT)) {
        self.predictions.push(item);

        return self.predictions.at(-1);
      }

      self.predictions.unshift(item);

      const record = self.predictions[0];

      return record;
    }

    function addAnnotation(options = {}) {
      options.type = "annotation";

      const item = createItem(options);

      if (item.userGenerate) {
        let actual_user;

        if (isFF(FF_DEV_3034)) {
          // drafts can be created by other user, but we don't have much info
          // so parse "id", get email and find user by it
          const email = emailFromCreatedBy(item.createdBy);
          const user = email && self.store.users.find((user) => user.email === email);

          if (user) actual_user = user.id;
        }
        item.completed_by = actual_user ?? getRoot(self).user?.id ?? undefined;
      }

      if (isFF(FF_SIMPLE_INIT)) {
        self.annotations.push(item);
      } else {
        self.annotations.unshift(item);
      }

      const record = self.annotations.at(isFF(FF_SIMPLE_INIT) ? -1 : 0);

      record.addVersions({
        result: options.result,
        draft: options.draft,
      });

      return record;
    }

    function createAnnotation(options = { userGenerate: true }) {
      const result = findNonInteractivePredictionResults();
      const c = self.addAnnotation({ ...options, result });

      if (result && result.length) {
        const ids = {};

        // Area id is <uniq-id>#<annotation-id> to be uniq across all tree
        result.forEach((r) => {
          if ("id" in r) {
            const id = r.id.replace(/#.*$/, `#${c.id}`);

            ids[r.id] = id;
            r.id = id;
          }
        });

        result.forEach((r) => {
          if (r.parent_id) {
            if (ids[r.parent_id]) r.parent_id = ids[r.parent_id];
            // impossible case but to not break the app better to reset it
            else r.parent_id = null;
          }
        });

        selectAnnotation(c.id);
        c.deserializeAnnotation(result);
        // reinit will trigger `updateObjects()` so we omit it here
        c.reinitHistory();
      } else {
        c.setDefaultValues();
      }
      return c;
    }

    function addHistory(options = {}) {
      options.type = "history";

      if (isFF(FF_DEV_3391)) {
        options.root = self.selected.root;
      }

      const item = createItem(options);

      self.history.push(item);

      const record = self.history[self.history.length - 1];

      return record;
    }

    function clearHistory() {
      self.history.forEach((item) => destroy(item));
      self.history.length = 0;
    }

    function selectHistory(item) {
      self.selectedHistory = item;
      setTimeout(() => {
        // update classifications after render
        const updatedItem = item ?? self.selected;

        Array.from(updatedItem.names.values())
          .filter((t) => t.isClassificationTag)
          .forEach((t) => t.updateFromResult([]));

        updatedItem?.results
          .filter((r) => r.area.classification)
          .forEach((r) => r.from_name.updateFromResult?.(r.mainValue));
      });

      getEnv(self).events.invoke("selectHistory", self.store, self.selected, self.selectedHistory);
    }

    function addAnnotationFromPrediction(entity) {
      // immutable work, because we'll change ids soon
      const s = entity._initialAnnotationObj.map((r) => ({ ...r }));
      const c = self.addAnnotation({ userGenerate: true, result: s });

      const ids = {};

      // Area id is <uniq-id>#<annotation-id> to be uniq across all tree
      s.forEach((r) => {
        if ("id" in r) {
          const id = r.id.replace(/#.*$/, `#${c.id}`);

          ids[r.id] = id;
          r.id = id;
        }
      });

      s.forEach((r) => {
        if (r.parent_id) {
          if (ids[r.parent_id]) r.parent_id = ids[r.parent_id];
          // impossible case but to not break the app better to reset it
          else r.parent_id = null;
        }
      });

      selectAnnotation(c.id);
      c.deserializeAnnotation(s);
      // reinit will trigger `updateObjects()` so we omit it here
      c.reinitHistory();

      // parent link for the new annotations
      if (entity.pk) {
        if (entity.type === "prediction") {
          c.parent_prediction = Number.parseInt(entity.pk);
        } else if (entity.type === "annotation") {
          c.parent_annotation = Number.parseInt(entity.pk);
        }
      }

      return c;
    }

    /** ERRORS HANDLING */
    const handleErrors = (errors) => {
      self.addErrors(errors);
    };

    const addErrors = (errors) => {
      const ids = [];

      const newErrors = [...(self.validation ?? []), ...errors].reduce((res, error) => {
        const id = error.identifier;

        if (ids.indexOf(id) < 0) {
          ids.push(id);
          res.push(error);
        }

        return res;
      }, []);

      self.validation = newErrors;
    };

    const afterCreate = () => {
      self._validator = new DataValidator();
      self._validator.addErrorCallback(handleErrors);
    };

    const beforeDestroy = () => {
      self._validator.removeErrorCallback(handleErrors);
    };

    const validate = (validatorName, data) => {
      return self._validator.validate(validatorName, data);
    };

    const resetAnnotations = () => {
      self.selected = null;
      self.selectedHistory = null;
      self.annotations = [];
      self.predictions = [];
      self.history = [];
    };

    return {
      afterCreate,
      beforeDestroy,

      toggleViewingAllAnnotations,

      initRoot,
      addToName,
      addName,
      upsertToName,

      addPrediction,
      addAnnotation,
      createAnnotation,
      addAnnotationFromPrediction,
      addHistory,
      clearHistory,
      selectHistory,

      addErrors,
      validate,

      selectAnnotation,
      selectPrediction,

      _selectItem,
      _unselectAll,

      deleteAnnotation,
      clearDeletedParents,
      resetAnnotations,
    };
  });

export default types.compose("AnnotationStore", AnnotationStoreModel, StoreExtender);
